// File created at: Friday, July 31, 2020
// File encoding  : UTF-8
// Line separator : LF
// Tab stop       : 4 spaces
// IDE            : IntelliJ IDEA community edition
package com.joelzho.vchat.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.websocket.Session;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * Chat service
 *
 * @author Joel
 */
@Component
public class ChatService {
    private static final Logger log = LoggerFactory.getLogger(ChatService.class);
    //
    private final Map<String, User> userSessionIDMap; // <sessionID, User>
    private final Map<String, User> userNicknameMap; // <nickname, User>
    private final ReadWriteLock usersRwLock;

    //
    public ChatService() {
        userSessionIDMap = new HashMap<>();
        userNicknameMap = new HashMap<>();
        usersRwLock = new ReentrantReadWriteLock();
    }

    /**
     * Get the number of online users
     *
     * @return num pf online users
     */
    public int numberOfUsers() {
        usersRwLock.readLock().lock();
        int cnt = userSessionIDMap.size();
        usersRwLock.readLock().unlock();
        return cnt;
    }

    /**
     * Login with session and nickname.
     * <p>
     * Only allows one user to login in a single session, and one session only allows one user logged in.
     * <p>
     * This will send a notification to all users if login successfully (except for repeat login).
     *
     * @param session  websocket session
     * @param nickname nickname
     * @return true if login successfully or already logged in, otherwise failed
     */
    public boolean login(@NotNull Session session, @NotNull String nickname) throws Exception {
        boolean success = false;
        boolean alreadyExist = false;
        final String sessionID = session.getId();
        usersRwLock.writeLock().lock();
        try {
            User user = userSessionIDMap.get(sessionID);
            if (user == null) {
                user = userNicknameMap.computeIfAbsent(nickname, (key) -> new User(session, nickname));
            } else {
                alreadyExist = true;
            }
            if (user.session.getId().equals(sessionID)) {
                userSessionIDMap.put(sessionID, user);
                success = true;
            }
        } finally {
            usersRwLock.writeLock().unlock();
        }
        if (success && !alreadyExist) {
            broadcastLoggedIn(nickname);
            log.info("a new user has been joined in chat: {}", nickname);
        }
        return success;
    }

    /**
     * Logout the user in current session.
     * <p>
     * This will send a notification to all users if logout successfully
     *
     * @param session session
     * @return true if logged out successfully, otherwise false
     */
    public boolean logout(@NotNull Session session) throws Exception {
        User removedUser;
        final String sessionID = session.getId();
        usersRwLock.writeLock().lock();
        try {
            removedUser = userSessionIDMap.remove(sessionID);
            if (removedUser != null)
                userNicknameMap.remove(removedUser.nickname);
        } finally {
            usersRwLock.writeLock().unlock();
        }
        boolean success = removedUser != null;
        if (success) {
            broadcastLoggedOut(removedUser.nickname);
            log.info("a user has been exited chat: {}", removedUser.nickname);
        }
        return removedUser != null;
    }

    /**
     * Send message to other users.
     *
     * @param session seesion
     * @param message message to send
     * @return -1: not logged in, otherwise has been sent to how many users
     */
    public int sendChatMessage(@NotNull Session session, @NotNull String message) throws Exception {
        usersRwLock.readLock().lock();
        User user = userSessionIDMap.get(session.getId());
        usersRwLock.readLock().unlock();

        if (user == null)
            return -1;

        ObjectMapper out = new ObjectMapper();
        ObjectNode objectNode = out.createObjectNode();
        objectNode.put("what", "new_chat_msg");
        objectNode.put("content", message);
        objectNode.put("sender", user.nickname);
        objectNode.put("timestamp", System.currentTimeMillis());
        return broadcast(out.writeValueAsString(objectNode));
    }

    private void broadcastLoggedOut(@NotNull String nickname) throws Exception {
        ObjectMapper out = new ObjectMapper();
        ObjectNode objectNode = out.createObjectNode();
        objectNode.put("what", "friend_logged_out");
        objectNode.put("content", nickname);
        broadcast(out.writeValueAsString(objectNode));
    }

    private void broadcastLoggedIn(@NotNull String nickname) throws Exception {
        ObjectMapper out = new ObjectMapper();
        ObjectNode objectNode = out.createObjectNode();
        objectNode.put("what", "friend_logged_in");
        objectNode.put("content", nickname);
        broadcast(out.writeValueAsString(objectNode));
    }

    private int broadcast(String content) {
        usersRwLock.readLock().lock();
        Map<String, User> temp = new HashMap<>(this.userNicknameMap);
        usersRwLock.readLock().unlock();
        int succesCnt = 0;
        for (Map.Entry<String, User> userEntry : temp.entrySet()) {
            Session s = userEntry.getValue().session;
            try {
                s.getBasicRemote().sendText(content);
                succesCnt++;
            } catch (Exception e) {
                log.error("send message to:[{}:{}], error:{}", s.getId(), userEntry.getKey(), e.getMessage());
            }
        }
        return succesCnt;
    }

    class User {
        User(Session session, String nickname) {
            this.session = session;
            this.nickname = nickname;
        }

        String nickname;
        Session session;
    }
}
